"""Exploration Mode core endpoints (MVP slice).

Endpoints implemented:
 - GET /explore/nodes
 - GET /explore/nodes/{id}
 - GET /explore/nodes/{id}/quests
 - POST /explore/quests/{id}/start
 - POST /explore/quests/{id}/progress
 - POST /explore/quests/{id}/finish
 - GET /explore/user/summary
 - GET /explore/nodes/{id}/lore

Further endpoints (edges, achievements, refresh lore, etc.) will be added later.
"""

from __future__ import annotations

from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import Any, Optional
import hashlib
import json

from app.database import get_db
from app.utils.auth import get_current_active_user, decode_token
from app.models.user import User
from app.models.exploration_core import (
    ExplorationNode,
    Quest,
    UserQuest,
    UserProgress,
    UserProfileExt,
    AiLoreCache,
    Achievement,
    UserAchievement,
    NodeEdge,
    MiniGameRecord,
)
from app.services.ai_service import ai_service
from app.seed.explore_seed import seed_exploration_if_empty

router = APIRouter()


# ---- Utility helpers ----
def _exp_for_level(level: int) -> int:
    return 100 + (level - 1) * 40


def _ensure_profile(db: Session, user_id: int) -> UserProfileExt:
    profile = db.query(UserProfileExt).filter(UserProfileExt.user_id == user_id).first()
    if not profile:
        profile = UserProfileExt(user_id=user_id)
        db.add(profile)
        db.commit()
        db.refresh(profile)
    return profile


def _maybe_level_up(profile: UserProfileExt) -> bool:
    leveled = False
    while profile.exp_total >= profile.next_level_exp:
        profile.level += 1
        profile.next_level_exp += _exp_for_level(profile.level)
        leveled = True
    return leveled


def _check_and_unlock_node(db: Session, user_id: int, node_id: int) -> None:
    """检查节点的所有任务是否都已完成，如果是则在UserProgress中标记为已解锁"""
    # 获取该节点的所有任务（应用与前端一致的过滤逻辑）
    quests = (
        db.query(Quest)
        .filter(Quest.node_id == node_id)
        .order_by(Quest.order_index.asc())
        .all()
    )

    if not quests:
        return

    # 应用READ任务过滤逻辑（与_compute_node_lock保持一致）
    filtered = []
    read_seen = False
    multi_reads = [q for q in quests if q.type == "READ"]

    if len(multi_reads) > 1:
        any_read_done = (
            db.query(UserQuest)
            .filter(
                UserQuest.user_id == user_id,
                UserQuest.quest_id.in_([q.id for q in multi_reads]),
                UserQuest.status == "DONE",
            )
            .count()
            > 0
        )
    else:
        any_read_done = False

    for q in quests:
        if q.type == "READ":
            if any_read_done:
                continue
            if read_seen:
                continue
            read_seen = True
        filtered.append(q)

    if not filtered:
        return

    # 检查所有有效任务是否都已完成
    quest_ids = [q.id for q in filtered]
    done_count = (
        db.query(UserQuest)
        .filter(
            UserQuest.user_id == user_id,
            UserQuest.quest_id.in_(quest_ids),
            UserQuest.status == "DONE",
        )
        .count()
    )

    # 如果所有任务都完成了，创建或更新UserProgress记录
    if done_count == len(filtered):
        progress = (
            db.query(UserProgress)
            .filter(UserProgress.user_id == user_id, UserProgress.node_id == node_id)
            .first()
        )
        if not progress:
            progress = UserProgress(user_id=user_id, node_id=node_id, unlocked=True)
            db.add(progress)
        else:
            progress.unlocked = True


def _compute_node_lock(
    db: Session, node: ExplorationNode, *, user: Optional[User]
) -> tuple[bool, Optional[str]]:
    """计算节点是否锁定及原因（MVP 规则）。

    规则：
    1) 若存在 NodeEdge(relation_type='unlock') 指向 node，需完成每个 from_node 的全部任务；未登录用户则锁定并提示登录。
    2) 若通过前置：默认解锁直接通过；否则需用户等级 >= node.level；未登录时给出通用提示。
    """
    # 前置边检查
    edges = (
        db.query(NodeEdge)
        .filter(NodeEdge.to_node_id == node.id, NodeEdge.relation_type == "unlock")
        .all()
    )
    if edges:
        if not user:
            return True, "需登录并完成前置节点任务"
        from app.models.exploration_core import Quest as QuestModel

        for e in edges:
            # 与前端任务列表保持一致：仅保留首个 READ，其余 READ 视为历史冗余不纳入解锁判断
            qs = (
                db.query(QuestModel)
                .filter(QuestModel.node_id == e.from_node_id)
                .order_by(QuestModel.order_index.asc())
                .all()
            )
            if qs:
                filtered = []
                read_seen = False
                # 兼容策略：若该节点存在多个历史 READ，且用户已完成至少1个 READ，则不强制保留首个 READ（视为 READ 条件满足）
                multi_reads = [q for q in qs if q.type == "READ"]
                if len(multi_reads) > 1:
                    any_read_done = (
                        db.query(UserQuest)
                        .filter(
                            UserQuest.user_id == user.id,
                            UserQuest.quest_id.in_([q.id for q in multi_reads]),
                            UserQuest.status == "DONE",
                        )
                        .count()
                        > 0
                    )
                else:
                    any_read_done = False

                for q in qs:
                    if q.type == "READ":
                        if any_read_done:
                            # 已有任一 READ 完成，后续 READ 不再纳入解锁判断
                            continue
                        if read_seen:
                            continue
                        read_seen = True
                    filtered.append(q)
                qids = [q.id for q in filtered]
                if qids:
                    done_cnt = (
                        db.query(UserQuest)
                        .filter(
                            UserQuest.user_id == user.id,
                            UserQuest.quest_id.in_(qids),
                            UserQuest.status == "DONE",
                        )
                        .count()
                    )
                    if done_cnt < len(qids):
                        from_node = (
                            db.query(ExplorationNode)
                            .filter(ExplorationNode.id == e.from_node_id)
                            .first()
                        )
                        title = from_node.title if from_node else "前置节点"
                        return True, f"需先完成《{title}》的全部任务"

    # 等级要求（默认解锁不再绕过等级限制）
    if not user:
        return True, f"需达到等级 Lv.{node.level}"
    profile = _ensure_profile(db, user.id)
    if profile.level < max(1, node.level):
        return True, f"需达到等级 Lv.{node.level}"
    return False, None


# ---- Schemas (local lightweight to avoid bloating global schema for now) ----
from pydantic import BaseModel, Field


class NodeOut(BaseModel):
    id: int
    code: str
    title: str
    short_desc: Optional[str]
    level: int
    category: Optional[str]
    parent_id: Optional[int]
    is_unlocked_default: bool
    # 计算字段：节点是否锁定及原因
    is_locked: bool = False
    locked_reason: Optional[str] = None

    class Config:
        from_attributes = True


class QuestOut(BaseModel):
    id: int
    node_id: int
    title: str
    type: str
    description: Optional[str]
    requirement_json: dict[str, Any] = Field(default_factory=dict)
    reward_exp: int
    status: str = "PENDING"
    # 针对已登录用户，服务端将回显其 progress_json（例如 READ 的 elapsed 秒数）
    progress_json: dict[str, Any] = Field(default_factory=dict)

    class Config:
        from_attributes = True


class UserSummaryOut(BaseModel):
    level: int
    exp_total: int
    next_level_exp: int
    quests_done: int
    nodes_unlocked: int


class QuestProgressIn(BaseModel):
    payload: dict[str, Any] = Field(default_factory=dict)


class LoreOut(BaseModel):
    node_id: int
    cached: bool
    content_md: str


# ---- Endpoints ----


@router.get("/nodes", response_model=list[NodeOut])
def list_nodes(
    level: Optional[int] = None,
    category: Optional[str] = None,
    parent_id: Optional[int] = None,
    db: Session = Depends(get_db),
    authorization: Optional[str] = Header(default=None),
):
    q = db.query(ExplorationNode)
    if level is not None:
        q = q.filter(ExplorationNode.level == level)
    if category:
        q = q.filter(ExplorationNode.category == category)
    if parent_id is not None:
        q = q.filter(ExplorationNode.parent_id == parent_id)
    rows = q.order_by(
        ExplorationNode.level.asc(), ExplorationNode.order_index.asc()
    ).all()
    # 解析当前用户（可选）
    current_user: Optional[User] = None
    if authorization and authorization.lower().startswith("bearer "):
        try:
            token = authorization.split(" ", 1)[1]
            payload = decode_token(token)
            uid = payload.get("sub")
            if uid is not None:
                u = db.query(User).filter(User.id == int(uid)).first()
                if u and u.is_active:
                    current_user = u
        except Exception:
            current_user = None
    out: list[NodeOut] = []
    for n in rows:
        item = NodeOut.model_validate(n)
        locked, reason = _compute_node_lock(db, n, user=current_user)
        item.is_locked = locked
        item.locked_reason = reason
        out.append(item)
    return out


@router.get("/nodes/{node_id}", response_model=NodeOut)
def get_node(
    node_id: int,
    db: Session = Depends(get_db),
    authorization: Optional[str] = Header(default=None),
):
    node = db.query(ExplorationNode).filter(ExplorationNode.id == node_id).first()
    if not node:
        raise HTTPException(404, "节点不存在")
    current_user: Optional[User] = None
    if authorization and authorization.lower().startswith("bearer "):
        try:
            token = authorization.split(" ", 1)[1]
            payload = decode_token(token)
            uid = payload.get("sub")
            if uid is not None:
                u = db.query(User).filter(User.id == int(uid)).first()
                if u and u.is_active:
                    current_user = u
        except Exception:
            current_user = None
    item = NodeOut.model_validate(node)
    locked, reason = _compute_node_lock(db, node, user=current_user)
    item.is_locked = locked
    item.locked_reason = reason
    return item


@router.get("/nodes/{node_id}/quests", response_model=list[QuestOut])
def get_node_quests(
    node_id: int,
    db: Session = Depends(get_db),
    authorization: Optional[str] = Header(default=None),
):
    node = db.query(ExplorationNode).filter(ExplorationNode.id == node_id).first()
    if not node:
        raise HTTPException(404, "节点不存在")
    quests = (
        db.query(Quest)
        .filter(Quest.node_id == node_id)
        .order_by(Quest.order_index.asc())
        .all()
    )
    # 尝试解析当前用户（可选）用于返回个性化状态
    current_user: Optional[User] = None
    if authorization and authorization.lower().startswith("bearer "):
        try:
            token = authorization.split(" ", 1)[1]
            payload = decode_token(token)
            uid = payload.get("sub")
            if uid is not None:
                u = db.query(User).filter(User.id == int(uid)).first()
                if u and u.is_active:
                    current_user = u
        except Exception:
            current_user = None

    user_quests_map: dict[int, UserQuest] = {}
    if current_user is not None:
        rows = (
            db.query(UserQuest)
            .filter(
                UserQuest.user_id == current_user.id,
                UserQuest.quest_id.in_([q.id for q in quests]),
            )
            .all()
        )
        user_quests_map = {r.quest_id: r for r in rows}
    out: list[QuestOut] = []
    # 仅保留第一个 READ 类型的任务，过滤掉多余的 READ
    filtered: list[Quest] = []
    read_seen = False
    for q in quests:
        if q.type == "READ":
            if read_seen:
                continue
            read_seen = True
        filtered.append(q)

    for q in filtered:
        data = QuestOut.model_validate(q)
        uq = user_quests_map.get(q.id) if current_user is not None else None
        if uq:
            data.status = uq.status
            try:
                data.progress_json = uq.progress_json or {}
            except Exception:
                data.progress_json = {}
        else:
            data.status = "PENDING"  # 未登录或未开始
        out.append(data)
    return out


# ---- Admin utilities (optional reseed) ----
@router.post("/admin/seed-sync")
def reseed_exploration(
    db: Session = Depends(get_db), user: User = Depends(get_current_active_user)
):
    """Re-apply idempotent exploration seed to add any missing nodes/quests/edges.

    Only allowed for admin users.
    """
    if not getattr(user, "is_admin", False):
        raise HTTPException(403, "需要管理员权限")
    try:
        summary = seed_exploration_if_empty(db)
        return {"status": "ok", "summary": summary}
    except Exception as e:
        raise HTTPException(500, f"seed 失败: {e}")


@router.post("/quests/{quest_id}/start", response_model=QuestOut)
def start_quest(
    quest_id: int,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_active_user),
):
    quest = db.query(Quest).filter(Quest.id == quest_id).first()
    if not quest:
        raise HTTPException(404, "任务不存在")
    # 校验所属节点是否解锁
    node = db.query(ExplorationNode).filter(ExplorationNode.id == quest.node_id).first()
    if not node:
        raise HTTPException(404, "所属节点不存在")
    locked, reason = _compute_node_lock(db, node, user=user)
    if locked:
        raise HTTPException(403, reason or "节点未解锁，无法开始任务")
    user_quest = (
        db.query(UserQuest)
        .filter(UserQuest.user_id == user.id, UserQuest.quest_id == quest_id)
        .first()
    )
    if not user_quest:
        user_quest = UserQuest(
            user_id=user.id,
            quest_id=quest_id,
            status="ACTIVE",
            started_at=datetime.utcnow(),
        )
        db.add(user_quest)
        db.commit()
    data = QuestOut.model_validate(quest)
    data.status = user_quest.status
    try:
        data.progress_json = user_quest.progress_json or {}
    except Exception:
        data.progress_json = {}
    return data


from datetime import datetime


@router.post("/quests/{quest_id}/progress", response_model=QuestOut)
def update_progress(
    quest_id: int,
    body: QuestProgressIn,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_active_user),
):
    quest = db.query(Quest).filter(Quest.id == quest_id).first()
    if not quest:
        raise HTTPException(404, "任务不存在")
    # 校验所属节点是否解锁（防越权推进）
    node = db.query(ExplorationNode).filter(ExplorationNode.id == quest.node_id).first()
    if not node:
        raise HTTPException(404, "所属节点不存在")
    locked, reason = _compute_node_lock(db, node, user=user)
    if locked:
        raise HTTPException(403, reason or "节点未解锁，无法上报进度")
    user_quest = (
        db.query(UserQuest)
        .filter(UserQuest.user_id == user.id, UserQuest.quest_id == quest_id)
        .first()
    )
    if not user_quest:
        raise HTTPException(400, "任务未开始")
    if user_quest.status != "ACTIVE":
        # DONE 或 FAILED 不再更新
        data = QuestOut.model_validate(quest)
        data.status = user_quest.status
        return data
    # 合并并重新赋值（避免原地 update 导致 JSON 未标记为脏而不提交）
    payload = body.payload or {}
    current = user_quest.progress_json or {}
    if not isinstance(current, dict):  # 保险
        current = {}
    merged = {**current, **payload}
    user_quest.progress_json = merged
    db.commit()
    data = QuestOut.model_validate(quest)
    data.status = user_quest.status
    try:
        data.progress_json = user_quest.progress_json or {}
    except Exception:
        data.progress_json = {}
    return data


class QuestFinishOut(BaseModel):
    quest: QuestOut
    gained_exp: int
    level_up: bool
    profile: UserSummaryOut
    achievements: list["AchievementOut"] = []  # 新获得的成就（本次）


class AchievementOut(BaseModel):
    id: int
    code: str
    title: str
    description: Optional[str]
    reward_points: int

    class Config:
        from_attributes = True


class AchievementStatusOut(AchievementOut):
    earned: bool = False
    earned_at: Optional[str] = None


@router.post("/quests/{quest_id}/finish", response_model=QuestFinishOut)
def finish_quest(
    quest_id: int,
    db: Session = Depends(get_db),
    user: User = Depends(get_current_active_user),
):
    quest = db.query(Quest).filter(Quest.id == quest_id).first()
    if not quest:
        raise HTTPException(404, "任务不存在")
    # 校验所属节点是否解锁（防绕过）
    node = db.query(ExplorationNode).filter(ExplorationNode.id == quest.node_id).first()
    if not node:
        raise HTTPException(404, "所属节点不存在")
    locked, reason = _compute_node_lock(db, node, user=user)
    if locked:
        raise HTTPException(403, reason or "节点未解锁，无法完成任务")
    user_quest = (
        db.query(UserQuest)
        .filter(UserQuest.user_id == user.id, UserQuest.quest_id == quest_id)
        .first()
    )
    if not user_quest:
        raise HTTPException(400, "任务未开始，请先点击“开始”")
    if user_quest.status == "DONE":
        raise HTTPException(400, "任务已完成，请勿重复完成")
    if user_quest.status != "ACTIVE":
        raise HTTPException(400, "任务未处于进行中状态")
    # --- 严格完成条件校验 ---
    # 基于 quest.type / requirement_json 对 user_quest.progress_json 进行验证
    req = quest.requirement_json or {}
    prog = user_quest.progress_json or {}
    # 服务器侧计算用时（如有 start 时间）
    server_elapsed_s: int | None = None
    try:
        if user_quest.started_at is not None:
            server_elapsed_s = int(
                (datetime.utcnow() - user_quest.started_at).total_seconds()
            )
    except Exception:
        server_elapsed_s = None

    # time_limit 校验（若配置存在），统一在各类型之前执行
    try:
        req_time_limit = None
        if isinstance(req, dict):
            tl = req.get("time_limit")
            if tl is not None:
                req_time_limit = int(tl)
        if req_time_limit and req_time_limit > 0 and server_elapsed_s is not None:
            if server_elapsed_s > req_time_limit:
                raise HTTPException(400, "未满足完成条件: 已超出时间限制")
    except HTTPException:
        raise
    except Exception:
        # 宽容：解析失败不触发时间限制
        pass

    if quest.type == "READ":
        # 约定: requirement_json.min_read_seconds; progress_json.elapsed
        raw_min = req.get("min_read_seconds") if isinstance(req, dict) else 0
        try:
            min_sec = int(raw_min) if raw_min is not None else 0
        except (TypeError, ValueError):
            min_sec = 0
        raw_elapsed = prog.get("elapsed") if isinstance(prog, dict) else 0
        try:
            elapsed = int(raw_elapsed) if raw_elapsed is not None else 0
        except (TypeError, ValueError):
            elapsed = 0
        if min_sec > 0 and elapsed < min_sec:
            raise HTTPException(400, "未满足完成条件: 阅读时长不足")
    elif quest.type == "VISIT":
        # 约定: requirement_json.target 可选；progress_json.visited 为 True 即可
        visited = False
        if isinstance(prog, dict):
            v = prog.get("visited")
            visited = bool(v)
        if not visited:
            raise HTTPException(400, "未满足完成条件: 尚未确认已阅/访问")
    elif quest.type == "COLLECT":
        # 约定: requirement_json.count; progress_json.count
        raw_need = (req or {}).get("count")
        try:
            need = int(raw_need) if raw_need is not None else 0
        except (TypeError, ValueError):
            need = 0
        raw_cnt = (prog or {}).get("count") if isinstance(prog, dict) else 0
        try:
            got = int(raw_cnt) if raw_cnt is not None else 0
        except (TypeError, ValueError):
            got = 0
        if need > 0 and got < need:
            raise HTTPException(400, "未满足完成条件: 收集数量不足")
    elif quest.type == "QUIZ_SELECT":
        # 约定: requirement_json: { mode: 'single'|'multi', answer: [..] }
        #      progress_json: { selected: [..] }
        mode = (req or {}).get("mode")
        answer = (req or {}).get("answer") or []
        selected = (prog or {}).get("selected") or []
        if not isinstance(answer, list) or not isinstance(selected, list):
            raise HTTPException(400, "未满足完成条件: 作答无效")
        # 归一化为字符串集合对比
        ans_set = {str(x) for x in answer}
        sel_set = {str(x) for x in selected}
        if mode == "single":
            # 单选必须且只能选择一个，并与标准答案完全一致
            if len(sel_set) != 1 or sel_set != ans_set:
                raise HTTPException(400, "未满足完成条件: 答案不正确")
        else:
            # 多选/默认：必须与标准答案集合完全一致
            if sel_set != ans_set:
                raise HTTPException(400, "未满足完成条件: 答案不正确")
    elif quest.type == "MATCH":
        # 要求：progress.matched 与 requirement.items 集合等价；或 progress.correct >= pairs
        pairs_need = 0
        items = []
        if isinstance(req, dict):
            try:
                pairs_need = int(req.get("pairs") or 0)
            except Exception:
                pairs_need = 0
            items = req.get("items") or []
        matched = []
        if isinstance(prog, dict):
            matched = prog.get("matched") or []

        # 规范化为集合（left|right 字符串去空白）
        def _norm_pair(p: dict) -> tuple[str, str]:
            l = str(p.get("left", "")).strip()
            r = str(p.get("right", "")).strip()
            return (l, r)

        items_set = {_norm_pair(p) for p in items if isinstance(p, dict)}
        matched_set = {_norm_pair(p) for p in matched if isinstance(p, dict)}
        # 判定：优先严格集合等价；若前端未回传 matched，则回退到 correct 计数
        passed = False
        if items_set and matched_set:
            passed = items_set == matched_set
        else:
            try:
                correct_num = int((prog or {}).get("correct") or 0)
            except Exception:
                correct_num = 0
            target_pairs = len(items_set) if items_set else pairs_need
            passed = target_pairs > 0 and correct_num >= target_pairs
        if not passed:
            raise HTTPException(400, "未满足完成条件: 配对不完整或不正确")
        # 记录小游戏结果
        try:
            duration_ms = int(max(0, server_elapsed_s or 0) * 1000)
            # 简单评分：基础 100 + 速度加成 - 容错惩罚
            speed_bonus = 0
            try:
                tl = int((req or {}).get("time_limit") or 0)
                if tl and server_elapsed_s is not None and server_elapsed_s < tl:
                    speed_bonus = max(0, (tl - server_elapsed_s) // 10)
            except Exception:
                speed_bonus = 0
            attempts = 0
            try:
                attempts = int((prog or {}).get("attempts") or 0)
            except Exception:
                attempts = 0
            penalty = max(0, attempts - (pairs_need or len(items_set) or 0))
            score_calc = max(0, 100 + speed_bonus - penalty)
            rec = MiniGameRecord(
                user_id=user.id,
                quest_id=quest.id,
                game_type="MATCH",
                input_payload={
                    "pairs": pairs_need or len(items_set),
                },
                result_payload={
                    "correct": (prog or {}).get("correct"),
                    "attempts": attempts,
                },
                score=score_calc,
                duration_ms=duration_ms,
            )
            db.add(rec)
        except Exception:
            pass
    elif quest.type == "ORDER":
        # 要求：current_order 与 requirement.sequence 完全一致
        seq = []
        initial = None
        if isinstance(req, dict):
            seq = list((req.get("sequence") or []))
            initial = req.get("initial")
        cur = []
        if isinstance(prog, dict):
            cur = list((prog.get("current_order") or []))
        # 长度一致 + 逐项相等
        if not (
            isinstance(seq, list)
            and isinstance(cur, list)
            and len(seq) == len(cur)
            and all(str(a) == str(b) for a, b in zip(seq, cur))
        ):
            raise HTTPException(400, "未满足完成条件: 顺序不正确")
        # 记录小游戏结果
        try:
            duration_ms = int(max(0, server_elapsed_s or 0) * 1000)
            speed_bonus = 0
            try:
                tl = int((req or {}).get("time_limit") or 0)
                if tl and server_elapsed_s is not None and server_elapsed_s < tl:
                    speed_bonus = max(0, (tl - server_elapsed_s) // 8)
            except Exception:
                speed_bonus = 0
            swap_count = 0
            try:
                swap_count = int((prog or {}).get("swap_count") or 0)
            except Exception:
                swap_count = 0
            penalty = max(0, swap_count - 0)
            score_calc = max(0, 100 + speed_bonus - penalty)
            rec = MiniGameRecord(
                user_id=user.id,
                quest_id=quest.id,
                game_type="ORDER",
                input_payload={
                    "length": len(seq),
                },
                result_payload={
                    "swap_count": swap_count,
                },
                score=score_calc,
                duration_ms=duration_ms,
            )
            db.add(rec)
        except Exception:
            pass
    # 未来可扩展: COLLECT / SCORE / COMBO 等类型
    # ----------------------------------------
    user_quest.status = "DONE"
    user_quest.finished_at = datetime.utcnow()

    profile = _ensure_profile(db, user.id)
    profile.exp_total += quest.reward_exp
    leveled = _maybe_level_up(profile)
    # 在测试环境中 sessionmaker(autoflush=False)，需要显式 flush 以便后续统计能看见最新状态
    try:
        db.flush()
    except Exception:
        pass

    # 检查该节点的所有任务是否都已完成，如果是则标记节点为已解锁
    _check_and_unlock_node(db, user.id, quest.node_id)

    # 成就扫描（延迟到任务状态/经验更新之后，单次提交即可）
    awarded = _scan_and_award_achievements(db, user.id, profile)

    quests_done_count = (
        db.query(UserQuest)
        .filter(UserQuest.user_id == user.id, UserQuest.status == "DONE")
        .count()
    )

    db.commit()

    qout = QuestOut.model_validate(quest)
    qout.status = user_quest.status
    try:
        qout.progress_json = user_quest.progress_json or {}
    except Exception:
        qout.progress_json = {}
    summary = UserSummaryOut(
        level=profile.level,
        exp_total=profile.exp_total,
        next_level_exp=profile.next_level_exp,
        quests_done=quests_done_count,
    )
    return QuestFinishOut(
        quest=qout,
        gained_exp=quest.reward_exp,
        level_up=leveled,
        profile=summary,
        achievements=[AchievementOut.model_validate(a) for a in awarded],
    )


@router.get("/user/summary", response_model=UserSummaryOut)
def user_summary(
    db: Session = Depends(get_db), user: User = Depends(get_current_active_user)
):
    profile = _ensure_profile(db, user.id)
    quests_done = (
        db.query(UserQuest)
        .filter(UserQuest.user_id == user.id, UserQuest.status == "DONE")
        .count()
    )
    # 统计已解锁的节点（通过UserProgress表）
    nodes_unlocked = (
        db.query(UserProgress)
        .filter(UserProgress.user_id == user.id, UserProgress.unlocked == True)
        .count()
    )
    return UserSummaryOut(
        level=profile.level,
        exp_total=profile.exp_total,
        next_level_exp=profile.next_level_exp,
        quests_done=quests_done,
        nodes_unlocked=nodes_unlocked,
    )


# --- Lore (AI 缓存) ---
class LoreRefreshIn(BaseModel):
    force: bool = False


def _lore_hash(node: ExplorationNode) -> str:
    raw = f"{node.id}|{node.title}|{(node.short_desc or '')[:100]}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:48]


def _generate_lore_content(node: ExplorationNode) -> str:
    # 优先使用真实 AI 服务；未配置或失败时回退到占位
    try:
        if getattr(ai_service, "llm", None):
            summary = (
                node.short_desc or ""
            ).strip() or "这是一段关于该节点的简要描述。"
            if (node.category or "").strip() in ("非遗", "非遗专题", "非物质文化遗产"):
                prompt = (
                    "你是一名非物质文化遗产的研究与传播专家，请为以下'非遗'主题生成一段富有温度与细节的'探索卷轴'，"
                    "使用 Markdown 输出，结构包含：\n"
                    "- 一级标题（项目名）\n"
                    "- 引言：点出历史渊源与文化价值\n"
                    "- ## 源流与价值：简述历史脉络与代表性传承\n"
                    "- ## 工艺流程：以条目形式拆解关键步骤（如选材/制坯/成形/装饰/烧制/表面处理等），每步 1-2 句要点\n"
                    "- ## 器具与材料：列出关键器具/材料及其作用\n"
                    "- ## 当代传承与创新：结合今天的保护与应用场景\n"
                    "- 引用块：给出一句富有情感的总结或寄语\n\n"
                    "要求：\n"
                    "- 使用中文，避免过度列表，不要返回 JSON\n"
                    "- 工艺流程部分务必贴合主题且具体\n\n"
                    f"主题：{node.title}\n"
                    f"线索：{summary}\n"
                )
            else:
                prompt = (
                    "你是古典知识世界观的编年史撰写者，请为以下主题生成一段富有意境的'探索卷轴'，使用 Markdown 输出，"
                    "结构包含：\n"
                    "- 一级标题（节点名）\n"
                    "- 一小段引言，点出该节点的意象与背景\n"
                    "- 至少三个小节（以 '##' 开头），分别讲述来源、内涵、今义\n"
                    "- 末尾用引用块给出一两句箴言式收束\n\n"
                    "要求：\n"
                    "- 使用中文，避免列表过长，不要返回 JSON\n"
                    "- 内容贴合主题，兼顾史识与审美\n\n"
                    f"主题：{node.title}\n"
                    f"线索：{summary}\n"
                )
            llm_obj = getattr(ai_service, "llm", None)
            resp = None
            # 优先使用 LangChain 的 invoke 接口（避免 __call__ 弃用警告）
            if hasattr(llm_obj, "invoke"):
                try:
                    resp = llm_obj.invoke(prompt, temperature=0.35, max_tokens=1500)  # type: ignore[attr-defined]
                except Exception:
                    resp = None
            elif callable(llm_obj):
                # 兼容旧路径
                try:
                    resp = llm_obj(prompt)
                except Exception:
                    resp = None
            content = resp if isinstance(resp, str) else ""
            content = content.strip()
            if content and len(content) > 40:
                return content
    except Exception:
        pass
    # 占位回退
    ts = datetime.utcnow().isoformat() + "Z"
    summary = node.short_desc or "暂无简介"
    return (
        f"### {node.title}\n\n"
        f"{summary}\n\n"
        f"> 自动生成的传说内容（占位）。\n\n"
        f"— lore v1 · {ts}"
    )


@router.get("/nodes/{node_id}/lore", response_model=LoreOut)
def get_lore(
    node_id: int,
    db: Session = Depends(get_db),
):
    node = db.query(ExplorationNode).filter(ExplorationNode.id == node_id).first()
    if not node:
        raise HTTPException(404, "节点不存在")
    h = _lore_hash(node)
    cache = (
        db.query(AiLoreCache)
        .filter(
            AiLoreCache.node_id == node.id,
            AiLoreCache.prompt_hash == h,
            AiLoreCache.locale == "zh-CN",
        )
        .first()
    )
    if cache:
        return LoreOut(node_id=node.id, cached=True, content_md=cache.content_md)
    # 占位（后续可接入真正 AI）
    placeholder = f"### {node.title}\n\n> 该节点的 AI 卷轴尚未生成，现为占位内容。短描述：{node.short_desc or '暂无'}。"
    return LoreOut(node_id=node.id, cached=False, content_md=placeholder)


@router.post("/nodes/{node_id}/lore/refresh", response_model=LoreOut)
def refresh_lore(
    node_id: int,
    body: LoreRefreshIn,
    db: Session = Depends(get_db),
):
    node = db.query(ExplorationNode).filter(ExplorationNode.id == node_id).first()
    if not node:
        raise HTTPException(404, "节点不存在")
    h = _lore_hash(node)
    locale = "zh-CN"
    cache = (
        db.query(AiLoreCache)
        .filter(
            AiLoreCache.node_id == node.id,
            AiLoreCache.prompt_hash == h,
            AiLoreCache.locale == locale,
        )
        .first()
    )
    if cache and not body.force:
        return LoreOut(node_id=node.id, cached=True, content_md=cache.content_md)
    content = _generate_lore_content(node)
    if cache:
        cache.content_md = content
    else:
        cache = AiLoreCache(
            node_id=node.id,
            locale=locale,
            prompt_hash=h,
            content_md=content,
        )
        db.add(cache)
    db.commit()
    return LoreOut(node_id=node.id, cached=True, content_md=cache.content_md)


# ---- Achievements ----


def _evaluate_achievement(
    cond: dict[str, Any],
    *,
    quests_done: int,
    profile: UserProfileExt,
    quests_done_by_type: dict[str, int],
    nodes_touched: int,
) -> bool:
    ctype = cond.get("type")
    val = cond.get("value")
    try:
        ival = int(val) if val is not None else None
    except (TypeError, ValueError):
        ival = None
    if ctype == "QUESTS_DONE_AT_LEAST" and ival is not None:
        return quests_done >= ival
    if ctype == "TOTAL_EXP_AT_LEAST" and ival is not None:
        return profile.exp_total >= ival
    if ctype == "LEVEL_REACHED" and ival is not None:
        return profile.level >= ival
    if ctype == "QUESTS_DONE_TYPE_AT_LEAST" and ival is not None:
        qtype = cond.get("quest_type")
        if not isinstance(qtype, str):
            return False
        return int(quests_done_by_type.get(qtype.upper(), 0)) >= ival
    if ctype == "NODES_TOUCHED_AT_LEAST" and ival is not None:
        return nodes_touched >= ival
    if ctype == "QUESTS_DONE_IN_CATEGORY_AT_LEAST" and ival is not None:
        cat = cond.get("category")
        if not isinstance(cat, str) or not cat:
            return False
        # 该条件需要在扫描函数中注入上下文，使用闭包外的变量不安全，这里仅返回 False；实际计算在扫描函数里展开
        return False
    if ctype == "NODES_TOUCHED_IN_CATEGORY_AT_LEAST" and ival is not None:
        cat = cond.get("category")
        if not isinstance(cat, str) or not cat:
            return False
        return False
    return False


def _scan_and_award_achievements(
    db: Session, user_id: int, profile: UserProfileExt
) -> list[Achievement]:
    # 统计数据一次性查询
    quests_done = (
        db.query(UserQuest)
        .filter(UserQuest.user_id == user_id, UserQuest.status == "DONE")
        .count()
    )
    # 按类型统计完成的任务数量
    type_rows = (
        db.query(Quest.type, func.count(UserQuest.id))
        .join(UserQuest, UserQuest.quest_id == Quest.id)
        .filter(UserQuest.user_id == user_id, UserQuest.status == "DONE")
        .group_by(Quest.type)
        .all()
    )
    quests_done_by_type = {(t or "").upper(): int(c or 0) for (t, c) in type_rows}
    # 触及的节点数（至少完成该节点的一项任务）
    nodes_touched = (
        db.query(Quest.node_id)
        .join(UserQuest, UserQuest.quest_id == Quest.id)
        .filter(UserQuest.user_id == user_id, UserQuest.status == "DONE")
        .distinct()
        .count()
    )
    # 分类维度统计
    # 1) 分类下完成的任务数量
    quests_done_in_category_rows = (
        db.query(ExplorationNode.category, func.count(UserQuest.id))
        .join(Quest, Quest.node_id == ExplorationNode.id)
        .join(UserQuest, UserQuest.quest_id == Quest.id)
        .filter(UserQuest.user_id == user_id, UserQuest.status == "DONE")
        .group_by(ExplorationNode.category)
        .all()
    )
    quests_done_in_category = {
        (cat or "").strip(): int(c or 0) for (cat, c) in quests_done_in_category_rows
    }
    # 2) 分类下触及的节点数量
    nodes_touched_in_category_rows = (
        db.query(ExplorationNode.category, func.count(func.distinct(Quest.node_id)))
        .join(Quest, Quest.node_id == ExplorationNode.id)
        .join(UserQuest, UserQuest.quest_id == Quest.id)
        .filter(UserQuest.user_id == user_id, UserQuest.status == "DONE")
        .group_by(ExplorationNode.category)
        .all()
    )
    nodes_touched_in_category = {
        (cat or "").strip(): int(c or 0) for (cat, c) in nodes_touched_in_category_rows
    }

    earned_rows = (
        db.query(UserAchievement.achievement_id)
        .filter(UserAchievement.user_id == user_id)
        .all()
    )
    earned_ids = {r[0] for r in earned_rows}
    candidates: list[Achievement] = (
        db.query(Achievement).filter(~Achievement.id.in_(earned_ids)).all()
        if earned_ids
        else db.query(Achievement).all()
    )
    newly: list[Achievement] = []
    total_points = 0
    for ach in candidates:
        cond = ach.condition_json or {}
        ok = _evaluate_achievement(
            cond,
            quests_done=quests_done,
            profile=profile,
            quests_done_by_type=quests_done_by_type,
            nodes_touched=nodes_touched,
        )
        if not ok:
            ctype = cond.get("type")
            val = cond.get("value")
            try:
                ival = int(val) if val is not None else None
            except (TypeError, ValueError):
                ival = None
            if ctype == "QUESTS_DONE_IN_CATEGORY_AT_LEAST" and ival is not None:
                cat = (cond.get("category") or "").strip()
                if cat:
                    ok = int(quests_done_in_category.get(cat, 0)) >= ival
            elif ctype == "NODES_TOUCHED_IN_CATEGORY_AT_LEAST" and ival is not None:
                cat = (cond.get("category") or "").strip()
                if cat:
                    ok = int(nodes_touched_in_category.get(cat, 0)) >= ival
        if ok:
            ua = UserAchievement(user_id=user_id, achievement_id=ach.id)
            db.add(ua)
            newly.append(ach)
            total_points += ach.reward_points or 0
    if newly and total_points:
        profile.achievement_points += total_points
    return newly


@router.get("/achievements", response_model=list[AchievementStatusOut])
def list_achievements(
    db: Session = Depends(get_db), user: User = Depends(get_current_active_user)
):
    profile = _ensure_profile(db, user.id)
    # 懒加载扫描：如果有新成就在任务完成后才被创建，也能在列表调用时授予
    newly = _scan_and_award_achievements(db, user.id, profile)
    if newly:
        db.commit()  # 提交授予
    earned_map = {
        ua.achievement_id: ua
        for ua in db.query(UserAchievement)
        .filter(UserAchievement.user_id == user.id)
        .all()
    }
    rows = db.query(Achievement).order_by(Achievement.id.asc()).all()
    out: list[AchievementStatusOut] = []
    for ach in rows:
        status = AchievementStatusOut.model_validate(ach)
        if ach.id in earned_map:
            status.earned = True
            status.earned_at = earned_map[ach.id].earned_at.isoformat()
        out.append(status)
    return out
